iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
AI & Data

零基礎 AI 入門!從 Wx+b 到熱門模型的完整之路!系列 第 16

【Day 16】從零開始拆 Transformer,原來 Encoder 是這樣運作的!

  • 分享至 

  • xImage
  •  

前言

這幾天我會陸續和大家介紹 Transformer 模型的結構細節。老實說這個模型的重要性真的不容小覷,它幾乎可以說是現在 AI 世界的核心。不誇張地說只要你搞懂了 Transformer,基本上就掌握了現今大多數主流 AI 模型的運作邏輯。過去那些模型(像是 RNN、LSTM)當然也有它們的貢獻,不過你不需要太執著於它們的細節,因為 Transformer 的出現,某種程度上已經統一了這個領域的主流架構。

所,為了讓大家能更扎實地理解這套系統,我會把整個 Transformer 拆解成幾個章節來慢慢講,每一部分都會盡量用清楚、直白的方式來說明,讓你不用被艱澀的數學或名詞卡住也能理解核心概念。

今天我們就從 Transformer 裡的一個關鍵模組Encoder開始談,可以說Encoder 是整個架構的基石。如果你能搞清楚 Encoder 的邏輯和它是怎麼處理資訊的,後面在看 Decoder 或更複雜的應用(像是 GPT 或 BERT)時就會順很多。所以這篇我會一步步拆解 Encoder 的基本組成、每個模組的功能,還有它們背後的設計理念,希望能幫助你真正理解這個影響深遠的系統到底是怎麼運作的。

Transfomer Encoder

Transformer 是一種很有意思的深度學習模型架構,核心是所謂的注意力機制(Attention Mechanism。這個架構最早是 2017 年由 Vaswani 等人在一篇叫《Attention is All You Need》的論文中提出的。雖然一開始是專門為自然語言處理(像是翻譯、對話生成)設計的,但後來也慢慢被用在其他領域,比如電腦視覺,甚至現在很多最強的 AI 模型,幾乎都是靠這個架構做出來的。
https://ithelp.ithome.com.tw/upload/images/20241004/20152236lsopWP4Zlm.png
Transformer 最厲害的一點就是它處理「序列資料」的效率特別高,尤其是面對很長的句子或段落時,表現依然穩定。那接下來我們可以先來看看它的 Encoder,也就是整個模型裡負責讀懂輸入資料的部分,到底是怎麼運作的。

Positional Encoding

傳統的時間序列模型像是 RNN,本身就具備遞迴結構,所以它會自然而然保留輸入資料的順序,也就是前後文的關聯。但 Transformer 完全不是這樣設計的,它靠的是平行運算,意思就是它在處理輸入時,根本不知道每個字是排在第幾個。因此為了讓 Transformer 也能理解順序,就需要額外的機制來補上這一塊資訊。

因此我們就會需要 Positional Encoding(位置編碼),它的做法是把每個詞在句子裡的位置,用一組數學方式編碼進詞向量裡。這個編碼會跟原本的embedding加在一起送進模型。
https://ithelp.ithome.com.tw/upload/images/20241004/20152236Oz6THlEMXd.png
而這個位置資訊的編碼方式,是靠正弦(sin)和餘弦(cos)函數來實現的,而其原因很簡單,因為這兩個函數的波動有週期性,可以用來表示變化的節奏,在偶數編號的維度用 sin 函數來表示
https://ithelp.ithome.com.tw/upload/images/20250930/20152236zATbzQhVLf.png
奇數編號的維度用 cos 函數來表示
https://ithelp.ithome.com.tw/upload/images/20250930/20152236bUG2CxJRyK.png
公式中的 pos 表示詞在整個句子中的位置,而 i 是詞向量的第幾個維度,d_model 是整個詞向量的總維度,其中當 i 越大,分母中的值也會越大,這會讓 sin/cos 的變化變得比較慢。這種設計會讓不同的維度以不同的頻率在震盪,進而讓模型能更精細地感受到每個詞在句子中所處的相對位置。

在公式裡那個看起來有點突兀的 10000,其實不是隨便挑的數字,它是一個縮放因子(scaling factor),目的是讓不同維度的變化頻率有所區隔。舉個簡單的比喻你可以把這整個 Positional Encoding 想像成一個「頻率混音器」,每個維度像是一條獨立的聲音軌,頻率高低不同但混在一起可以幫助模型聽出句子中每個詞的位置。

具體來說低維度的變化比較劇烈(頻率高),高維度則變化得比較慢(頻率低),這種設計能讓模型從不同角度感受到詞序的影響,就像同一個場景用廣角與長焦鏡頭各拍一張照片一樣,提供多層次的空間訊息。而在程式碼中我們可以這樣撰寫

import torch
import torch.nn as nn
import math

class PositionalEncoding(nn.Module):
    def __init__(self, d_model, max_len=5000):
        super(PositionalEncoding, self).__init__()

        # 建立 (max_len, d_model) 大小的位置編碼矩陣
        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)  # shape: (max_len, 1)
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * 
                             (-math.log(10000.0) / d_model))

        # 偶數維度用 sin,奇數維度用 cos
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)

        # 增加 batch 維度方便加到輸入上
        pe = pe.unsqueeze(0)  # shape: (1, max_len, d_model)

        # 註冊 buffer,不會更新參數但會一起移到 GPU
        self.register_buffer('pe', pe)

    def forward(self, x):
        """
        x shape: (batch_size, seq_len, d_model)
        """
        seq_len = x.size(1)
        # 加上對應長度的 positional encoding
        return x + self.pe[:, :seq_len, :]

再來講講 max_len 的角色,這個參數其實是告訴模型你最多會處理多長的句子。在初始化位置編碼時,我們會先建立一個尺寸為 (max_len, d_model) 的矩陣,意思是我先準備好所有從第 0 個詞到第 max_len-1 個詞的所有位置編碼。不管你之後輸入的句子多長,我都有辦法從這個表裡撈出對應的那一段位置資訊來加進去。如果你的模型只會處理短句子,比如最多 128 個 token,那就可以把 max_len 設成 128。相對地,如果你在處理長篇文本(像是摘要、小說等),那就要把 max_len 設得大一點,避免在 forward 時出現超出範圍的錯誤。

還有一點比較進階但很重要的register_buffer,這不是普通的變數註冊,而是 PyTorch 中用來儲存不參與訓練但又需要跟著模型移動(像是轉到 GPU 上)」的資料。也就是說我們並不希望這個位置編碼在訓練過程中被改動,但又不能把它當成一般常數,因為它得跟著模型搬到 CUDA 裡才能正確運作。register_buffer 就是解這個問題的標準做法。

Multi-Head Self-Attention

在 Transformer 架構裡最核心的技術就是 Self-Attention(自注意力機制),這個機制有點像是模型在讀一段文字時會自己去看整個句子,判斷哪些詞跟現在這個詞有關係,然後把注意力放在那些比較重要的詞上。也因為這樣,Transformer 才能慢慢取代像 Seq2Seq 那種比較傳統、需要 Encoder 跟 Decoder 不斷互動的架構,變得又快又準。

講到自注意力,會牽扯到三種向量查詢(Query, Q)鍵(Key, K)值(Value, V),每個輸入的詞(也就是 Token)都會被轉換成這三種向量。怎麼轉?其實就是把原本的詞向量(通常是 embedding)乘上三個不同的權重矩陣,分別是W_QW_KW_V,你可以想像成是先經過一層 embedding,再用三個不同的線性層(nn.Linear)做運算。
https://ithelp.ithome.com.tw/upload/images/20241004/20152236gfynWO25qZ.png
而在上圖中的動作簡單來說,Q 就是拿來問問題的,K 是拿來比對的,而 V 是答案內容。接下來的流程是這樣:我們會拿查詢向量 Q 去跟所有詞的鍵向量 K 做點積,算出一個數值這個數值代表兩個詞之間的關聯程度,稱為 Attention Score。
https://ithelp.ithome.com.tw/upload/images/20250930/20152236HR0OXV5Zrr.png
然後我們會把這些 Score 丟進 Softmax,把它們變成一組機率,這組機率就是所謂的 Attention Weights,也就是我現在該多關注哪個詞的分數。https://ithelp.ithome.com.tw/upload/images/20250930/20152236B40ltCePbH.png
最後我們用這些機率去加權每個詞的值向量 V,加總後就得到這次注意力機制的輸出。
https://ithelp.ithome.com.tw/upload/images/20250930/20152236IAquFJaBOf.png
其中 √𝑑 這是 Q 和 K 向量的維度大小開根號(也就是詞向量空間)。為什麼要除這個?因為當向量維度太高時,Q 和 K 的點積結果可能會變得非常大,導致 Softmax 結果變得很極端,模型就學不好了。所以我們會用這個值來做縮放,把結果拉回合理的範圍。
https://ithelp.ithome.com.tw/upload/images/20241004/20152236wWAreaIFOw.png
講到這裡其實 Transformer 真正厲害的地方在於它用的是Multi-Head Self-Attention(多頭自注意力機制),不是只有一個頭在做注意力運算,而是會把 Q、K、V 拆成好幾組,每組都各自計算注意力,最後再把這些結果合起來,也就是以下的公式
https://ithelp.ithome.com.tw/upload/images/20241004/20152236x6yM0z6b9V.png
每個 attention head 本質上就是一次 Attention(Q, K, V) 的運算,而為什麼要用多個 head 呢?簡單來說,這樣做的好處是,每個頭可以專心」輸入句子的不同面向。像是有的 head 可能比較關注句子的語法結構,有的可能在抓語氣或情緒,還有的也許專注在主題相關的詞,這種設計讓模型能從多個角度來理解整個句子。

不過這邊有個小細節要注意,我們在計算 Attention 分數的時候有一個除以 √d 的操作,這個 d代表的是每個 head 的向量維度,既然我們把整體的 d_model 切成多個 head,那每個 head 的向量空間就要平均分配,這樣 scale 才會算得對。所以在程式碼裡,可以看到我們是把整個詞向量的維度平均分成多份:

class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, nhead, dropout=0.1):
        super(MultiHeadAttention, self).__init__()
        assert d_model % nhead == 0
        
        self.d_model = d_model
        self.nhead = nhead
        self.d_k = d_model // nhead
        
        self.w_q = nn.Linear(d_model, d_model)
        self.w_k = nn.Linear(d_model, d_model)
        self.w_v = nn.Linear(d_model, d_model)
        self.w_o = nn.Linear(d_model, d_model)
        
        self.dropout = nn.Dropout(dropout)
        self.scale = math.sqrt(self.d_k)

而在前向傳播時這邊不使用 torch.cat 來合併,而是用了 view + transpose,目的是把整個向量拆成多個子空間,好讓每個 head 處理屬於自己的那一份資料。如果使用cat會需要使用迴圈,而選擇 viewtranspose 只是比較快的做法。

    def forward(self, query, key, value, mask=None, key_padding_mask=None):
        batch_size = query.size(0)
        seq_len_q = query.size(1)
        seq_len_k = key.size(1)
        
        # 將 Q/K/V 映射後 reshape 成 (batch_size, nhead, seq_len, d_k)
        Q = self.w_q(query).view(batch_size, seq_len_q, self.nhead, self.d_k).transpose(1, 2)
        K = self.w_k(key).view(batch_size, seq_len_k, self.nhead, self.d_k).transpose(1, 2)
        V = self.w_v(value).view(batch_size, seq_len_k, self.nhead, self.d_k).transpose(1, 2)
        
        # 計算注意力分數
        scores = torch.matmul(Q, K.transpose(-2, -1)) / self.scale
        
        # 套用 attention mask
        if mask is not None:
            # 確保 mask 的形狀正確
            if mask.dim() == 2:
                mask = mask.unsqueeze(0).unsqueeze(0)  # (1, 1, seq_len, seq_len)
            elif mask.dim() == 3:
                mask = mask.unsqueeze(1)  # (batch_size, 1, seq_len, seq_len)
            scores = scores.masked_fill(mask, float('-inf'))
        
        # 套用 padding mask
        if key_padding_mask is not None:
            # key_padding_mask: (batch_size, seq_len_k)
            # 需要擴展為 (batch_size, 1, 1, seq_len_k)
            key_padding_mask = key_padding_mask.unsqueeze(1).unsqueeze(2)
            scores = scores.masked_fill(key_padding_mask, float('-inf'))
        
        # 計算注意力權重
        attn_weights = F.softmax(scores, dim=-1)
        attn_weights = self.dropout(attn_weights)
        
        # 計算 weighted sum 後 reshape 回原始形狀
        context = torch.matmul(attn_weights, V)
        context = context.transpose(1, 2).contiguous().view(
            batch_size, seq_len_q, self.d_model
        )
        
        # 最終輸出線性變換
        output = self.w_o(context)
        return output

而在 Attention 分數計算中,我們其實是把每個詞對其他所有詞都看一遍,但我們不希望模型「偷看」,這時就要用到 mask,舉個例子:

  • 在語言模型裡,我們不希望模型在預測第 t 個詞的時候,看到第 t+1、t+2 的詞,所以要遮住未來。
  • 在處理不同長度句子的時候,為了讓它們長度一致,我們會加上 <pad> token,但這些其實沒有意義,也要遮起來,不然模型可能會把注意力浪費在這些 padding 上。

這兩種情況分別會用到:

  • Attention Mask:避免看到未來的詞。
  • Key Padding Mask:忽略 padding 的位置。
    遮的方式其實也不難,就是把不想看的地方換成 -inf,這樣在做 softmax 的時候,那些位置就會變成 0,模型自然就不會理它們。

FeedForward

當我們在講 Transformer 的架構時,除了大家常提到的 Attention,其實還有一個很重要但常被忽略的部分前饋神經網路(FeedForward Network, FFN)

這一層的設計其實不複雜,就像是兩個線性層夾一個非線性函數,你可以把它想像成每個詞在經過 Attention 和其他詞聊天溝通後,還需要回過頭來自己想一想,把剛剛收到的資訊消化一下、重新組織,提煉出更有代表性的內部特徵。

import torch
import torch.nn as nn
import torch.nn.functional as F

class FeedForward(nn.Module):
    def __init__(self, d_model, d_ff, dropout=0.1, activation='gelu'):
        super(FeedForward, self).__init__()
        self.linear1 = nn.Linear(d_model, d_ff)
        self.linear2 = nn.Linear(d_ff, d_model)
        self.dropout = nn.Dropout(dropout)
        
        if activation == 'relu':
            self.activation = F.relu
        elif activation == 'gelu':
            self.activation = F.gelu
        else:
            raise ValueError('Unsupported activation function: {}'.format(activation))
    
    def forward(self, x):
        return self.linear2(self.dropout(self.activation(self.linear1(x))))

簡單來說,它就是:

  1. 把輸入丟進第一層線性轉換(通常維度會變大);
  2. 接個非線性函數讓資料「彎一下」;
  3. dropout 避免 overfitting;
  4. 再轉回原本的維度。
    那為什麼需要這一層?因為 Attention 在處理的是詞與詞之間的「關係」,比如說「你說了什麼、我怎麼回應」這種互動。而 FFN 則是讓每個詞有自己的「內心戲」,可以獨立思考、加工資訊,強化自己的表示能力。

這種搭配其實就像是你開完會(Attention),回到座位還是要自己整理筆記、做功課(FFN),這樣整體的表現才會更強。

Layer Normalization

另一個值得注意的地方是,在 Transformer 裡面引入了所謂的 Layer Normalization。簡單講它的作用就是幫每一層的輸入做個標準化處理,讓輸出的結果更穩定,這樣整個模型在訓練時就比較容易收斂,也能跑得更快、更穩定。雖然它聽起來有點像 Batch Normalization,但其實兩者做法不太一樣 Layer Norm 是針對每個樣本自己做正規化,而不是像 Batch Norm 那樣,是一整批資料一起處理。
https://ithelp.ithome.com.tw/upload/images/20241004/20152236z6j2q0LooM.png
你可能有注意到公式裡會出現個 ε這個小符號,它的主要功能其實就是防止在運算過程中除以零這種尷尬情況發生。所以它通常會被設得非常小,基本上就是個保險機制,確保計算的穩定性。而 γ則是控制輸出要放大多少的參數,它其實會在每一層都被調整,讓模型可以學會不同特徵的重要性。至於 β,它的角色是偏移量,讓模型可以微調輸出的整體分佈,讓結果更貼近真實數據的樣貌。

Encoder Skip Connection

現在我們來把這些零件組起來看一下。你會發現在 Encoder 的程式碼裡有兩次出現 output + layer['dropout'](src2) 這樣的寫法,這其實就是大家常提到的 Skip Connection(跳躍連接),也有人叫它 Residual Connection(殘差連接)

那這東西到底有什麼用?你可以想像一下,神經網路一層一層往下走,訊息每經過一層就會被改寫一次,但有時候改著改著,原本的重要訊息可能就不見了或者變得模糊了。Skip Connection 的概念就是不要把原始訊息整個丟掉,讓它繞個小路走旁邊,再回來跟處理後的結果合在一起,這樣做有兩個好處:

  • 幫助梯度流動:尤其是網路層很多的時候,這種跳躍連接可以減少梯度消失的問題,讓整個模型比較好訓練。
  • 保留原始資訊:讓後面的層還能看到一點原本輸入的樣子,不會完全被加工得面目全非。
    所以Encoder 大致上會長這樣:
class Encoder(nn.Module):
    def __init__(self, d_model, nhead, d_ff, num_layers, dropout=0.1, norm=None):
        super(Encoder, self).__init__()
        self.layers = nn.ModuleList([
            nn.ModuleDict({
                'self_attn': MultiHeadAttention(d_model, nhead, dropout),
                'feed_forward': FeedForward(d_model, d_ff, dropout),
                'norm1': nn.LayerNorm(d_model),
                'norm2': nn.LayerNorm(d_model),
                'dropout': nn.Dropout(dropout)
            }) for _ in range(num_layers)
        ])
        self.num_layers = num_layers
        self.norm = norm

    def forward(self, src, mask=None, src_key_padding_mask=None):
        output = src
        for layer in self.layers:
            # 自注意力 + Skip Connection + LayerNorm
            src2 = layer['self_attn'](output, output, output, mask, src_key_padding_mask)
            output = layer['norm1'](output + layer['dropout'](src2))

            # 前饋網路 + Skip Connection + LayerNorm
            src2 = layer['feed_forward'](output)
            output = layer['norm2'](output + layer['dropout'](src2))

        if self.norm is not None:
            output = self.norm(output)
        return output

整個流程就是這樣src2 是注意力學出來的新訊息,而 output 是進來這一層的輸入,兩個加在一起,再丟進 LayerNorm,這樣就可以把新的資訊跟原本的訊息自然地融合起來。這個設計真的很關鍵,可以說是 Transformer 成功的秘密武器之一,因為這樣不只穩定訓練,還不太容易出現梯度爆炸或訓練發散的問題。

下集預告

明天我們會來聊聊 Transformer Encoder的經典代表BERT,這個模型從推出以來一直是自然語言處理領域的主力選手,至今仍被廣泛應用在各種任務上。明天會聚焦在它的整體架構、與原始 Transformer 的差異,以及它為什麼能夠這麼強大。而明天不會有數學而是基於今天內容的模型講解,目標是讓你真正理解 BERT 究竟做對了什麼,才讓它能紅這麼久、用得這麼廣。


上一篇
【Day 15】Attention is All You Need?先別急來看看 LSTM 的最後一舞
下一篇
【Day 17】只懂 Wx + b 也能搞懂 BERT?當然可以!
系列文
零基礎 AI 入門!從 Wx+b 到熱門模型的完整之路!24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言